NOTE

C언어 기반 네트워크 데몬 및 CLI 명령어 파싱에서 필수적으로 사용되는 strtok_r과 sscanf의 내부 동작 원리, 포인터 메모리 변화(&save), 그리고 실무에서의 선택 기준을 정리한 문서.


1. 개요

네트워크 프로토콜 페이로드(예: tai|sst|sd, tai|callback_uri)나 대화형 CLI 명령어(예: put amf-1 45008 1 000001)를 다룰 때 C언어에서는 문자열을 정밀하게 분해·파싱해야 한다.

이때 가장 널리 쓰이는 두 표준 함수가 **strtok_r()**과 **sscanf()**이다.


2. strtok vs strtok_r (왜 _r이 붙었는가?)

2.1 구식 strtok의 치명적 한계: Thread-Unsafe

strtok(char *str, const char *delim)은 문자열을 분해할 때 **“내가 방금 어디까지 읽었는가?”**라는 위치 정보를 **함수 내부의 정적 전역 변수(static char *olds)**에 저장한다.

sequenceDiagram
    participant T1 as Thread 1 (AMF Service)
    participant S as strtok 내부 정적 포인터
    participant T2 as Thread 2 (Receiver)

    T1->>S: strtok(line1, " ") 호출 → 내부 포인터에 line1 중간 주소 저장
    T2->>S: strtok(line2, " ") 호출 → line1 주소를 line2 주소로 덮어써버림!
    T1->>S: strtok(NULL, " ") 호출 → 엉뚱한 line2 메모리를 읽고 Crash/Data Corruption!
  • 문제점: 멀티스레드 환경에서 여러 스레드가 동시에 strtok을 호출하면 서로의 탐색 위치를 덮어쓰는 **경쟁 상태(Race Condition)**가 발생하여 심각한 메모리 오류가 일어난다.

2.2 strtok_r의 해결책: Reentrant (재진입 가능)

  • 함수 끝의 **_r은 Reentrant (재진입 가능, Thread-safe)**를 의미한다.
  • 함수 내부에 정적 변수를 두지 않고, 호출하는 개발자가 위치를 기억할 포인터 변수(char *save)를 직접 스택에 만들어서 주소(&save)를 넘겨주도록 설계되었다.
  • 각 스레드가 자신만의 스택 프레임에 save 변수를 가지므로, 수백 개의 스레드가 동시에 실행되어도 완벽하게 독립적으로 동작한다.

3. char *save와 &save의 내부 동작 원리

3.1 함수 원형

char *strtok_r(char *str, const char *delim, char **saveptr);
  • str: 분해할 문자열의 시작 주소 (첫 호출 시 전달, 이후 호출 시 NULL)
  • delim: 구분자 문자들의 집합 (예: " ")
  • saveptr: 다음 번 탐색 시작 위치를 저장할 포인터 변수의 주소 (char **)

3.2 단계별 메모리 변화 상세

예를 들어 사용자 입력 문자열 line이 "get 45008"일 때:

char line[] = "get 45008";
char *save = NULL;
 
// 1. 첫 번째 호출
char *cmd = strtok_r(line, " ", &save);

[1단계] 첫 번째 호출 시 동작

  1. strtok_r은 첫 번째 구분자(공백 ' ')를 찾는다.
  2. 그 공백 자리에 � (NULL 종료 문자)를 강제로 덮어써서 문자열을 끊어버린다!
  3. 그리고 다음 글자인 '4'의 메모리 주소를 save 포인터 변수에 기록한다.
[메모리 상태 변화 1]
원본 line:  'g'  'e'  't'  ' '  '4'  '5'  '0'  '0'  '8'  '�'
                            ↓ (공백을 '�'로 강제 치환!)
수정 후:    'g'  'e'  't'  '�' '4'  '5'  '0'  '0'  '8'  '�'
             ↑                   ↑
          cmd 반환              save 포인터가 가리키는 위치
          ("get")               ('4'의 주소)

Q. 왜 save가 아니라 &save를 넘기는가?
save 변수 자체는 char *(주소를 담는 변수)이다. strtok_r 함수 내부에서 save 변수의 값(가리키는 주소)을 직접 바꿔주어야 하므로, **변수의 주소인 이중 포인터(char **)**를 전달하는 Call by Reference 방식이다.


[2단계] 두 번째 이후 호출 시 동작

// 2. 두 번째 호출
char *arg = strtok_r(NULL, " ", &save);
  • 첫 번째 인자에 NULL을 넘기는 이유:
    • “새로운 문자열을 처음부터 읽지 말고, 아까 save에 저장해 둔 위치부터 이어서 읽어라!”라는 규약이다.
  • strtok_r은 save가 가리키고 있던 '4'부터 탐색을 시작하여 문자열 끝(�)을 만나면 "45008"을 반환한다.
  • 더 이상 읽을 단어가 없으면 NULL을 반환하여 파싱 루프가 종료된다.

4. sscanf의 원리와 고급 패턴 매칭

4.1 sscanf 개념

  • scanf가 키보드(stdin)에서 입력을 받는다면, **sscanf (String Scan Formatted)**는 **이미 메모리에 존재하는 문자열 버퍼(char *)**에서 지정한 서식(Format)대로 데이터를 한 번에 추출한다.

4.2 실무 프로토콜 파싱 패턴 분석

실제 NSSF 핸들러에 구현된 코드 예시:

sscanf(pcPayload, "%31[^|]|%255s", szTai, szCallback)

이 서식 지정자는 3단계로 분해된다:

           ┌── 1. szTai에 담을 문자열 (%31[^|])
           │         ┌── 2. 건너뛸 리터럴 구분자 (|)
           │         │   ┌── 3. szCallback에 담을 문자열 (%255s)
sscanf(..., "%31[^|]  |   %255s", szTai, szCallback)
  1. %31[^|] (정규식 스타일 Negated Scanset):
    • [^|]: 파이프(|)가 나오기 전까지의 모든 문자를 읽어라.
    • 31: 버퍼 오버플로우 방지 폭(Width) 지정자. szTai 버퍼(32바이트)를 넘지 않도록 최대 31글자까지만 읽고 끝에 �을 자동 삽입.
  2. | (리터럴 매칭 & 스킵):
    • 문자열 중간의 | 문자를 읽어서 일치함을 확인하고 버린다.
  3. %255s:
    • | 뒤에 이어지는 공백 없는 문자열을 최대 255글자까지 읽어 szCallback에 저장.

4.3 sscanf의 반환값(Return Value) 검증의 중요성

  • sscanf는 **“성공적으로 매핑되어 채워진 인자의 개수”**를 반환한다.
  • szTai와 szCallback 두 개가 모두 완벽히 추출되었을 때만 2를 반환한다.
  • 만약 페이로드가 "45008|" 처럼 뒤가 누락되었거나 형식이 깨진 경우:
    • sscanf는 1 또는 0을 반환한다.
    • 따라서 실무 코드에서는 반드시 **if (sscanf(...) != 2)**로 검사하여 400 Bad Request 에러를 방어해야 한다.

5. strtok_r vs sscanf 핵심 비교 및 실무 선택 기준

비교 항목strtok_rsscanf
핵심 목적구분자로 구분된 단어들을 순차적으로 쪼갤 때정해진 포맷 템플릿에서 여러 필드를 한 번에 추출할 때
타입 변환문자열(char*) 토큰만 추출문자열(%s), 정수(%d), 실수(%f) 등 자동 변환 지원
원본 문자열 보존❌ 원본 훼손 (구분자 자리를 �로 덮어씀)⭕ 원본 보존 (읽기 전용 버퍼 그대로 유지)
const char* 사용불가능 (컴파일 경고 또는 실행 시 Segfault)가능 (const char* 입력 안전하게 파싱)
스레드 안전성⭕ Thread-safe (&save를 통한 컨텍스트 보존)⭕ Thread-safe (로컬 버퍼에 읽기만 수행)
주요 활용 처CLI 명령어 파싱 (cmd, arg1, arg2 …)고정 프로토콜 바디 파싱 (`tai

6. 실무 코드 작성 시 주의사항 요약

  1. strtok_r에 문자열 리터럴을 넘기지 말 것:
    • strtok_r("hello world", " ", &save); → 리터럴 영역 메모리 수정 시도로 Segmentation Fault 발생! 반드시 수정 가능한 char buf[] 배열을 넘겨야 한다.
  2. sscanf 문자열 입력 시 버퍼 폭(Width)을 반드시 명시할 것:
    • %s 대신 %31s처럼 수신 버퍼 크기(sizeof(buf) - 1)를 지정해야 버퍼 오버플로우 보안 취약점을 방지할 수 있다.
  3. 반복 파싱 시 save 변수의 초기화:
    • 새 문자열을 처음 파싱할 때는 반드시 char *save = NULL;로 초기화하고 첫 호출에 대상 문자열 포인터를 넘겨야 한다.